const std = @import("std");
const log = std.log.scoped(.run);
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const Component = std.Uri.Component;
const builtin = @import("builtin");
const tracy = @import("tracy");
const ziggy = @import("ziggy");
const supermd = @import("supermd");
const fatal = @import("fatal.zig");
const worker = @import("worker.zig");
const context = @import("context.zig");
const Build = @import("Build.zig");
const Variant = @import("Variant.zig");
const StringTable = @import("StringTable.zig");
const String = StringTable.String;
const PathTable = @import("PathTable.zig");
const Path = PathTable.Path;
const PathName = PathTable.PathName;

pub var progress_buf: [4096]u8 = undefined;
pub var progress: std.Progress.Node = undefined;

pub const Site = struct {
    /// Title of the website
    title: []const u8,
    /// URL where the website will be hosted.
    /// It must not contain a subpath.
    host_url: []const u8,
    /// Set this value if your website is hosted under a subpath of `host_url`.
    ///
    /// `host_url` and `url_prefix_path` are split to allow the development
    /// server to generate correct relative paths when serving the website
    /// locally.
    url_path_prefix: []const u8 = "",
    layouts_dir_path: []const u8,
    content_dir_path: []const u8,
    assets_dir_path: []const u8,
    /// Subpaths in `assets_dir_path` that will be installed unconditionally.
    /// All other assets will be installed only if referenced by a content file
    /// or a layout by calling `$site.asset('foo').link()`.
    ///
    /// Examples of incorrect usage of this field:
    /// - site-wide CSS files (should be `link`ed by templates)
    /// - RSS feeds (should be generated by defining `alternative` pages)
    ///
    /// Examples of correct usage of this field:
    /// - `favicon.ico` and other similar assets auto-discovered by browsers
    /// - `CNAME` (used by GitHub Pages when you set a custom domain)
    static_assets: []const []const u8 = &.{},
    /// When enabled, Zine will automatically add 'width' and 'height'
    /// attributes to <img> elements for local assets.
    /// Be aware that setting 'width' and 'heigth' of an image will in some
    /// circumstances cause browsers to distort images.
    ///
    /// This problem can be solved by adding the following CSS code to your
    /// site:
    ///
    /// img {
    ///    height: auto;
    /// }
    image_size_attributes: bool = false,
};

pub const MultilingualSite = struct {
    /// URL where the website will be hosted.
    /// It must not contain a path other than '/'.
    host_url: []const u8,
    /// Directory that contains mappings from placeholders to translations,
    /// expressed as Ziggy files.
    ///
    /// Each Ziggy file must be named after the locale it's meant to offer
    /// translations for.
    i18n_dir_path: []const u8,
    layouts_dir_path: []const u8,
    assets_dir_path: []const u8,
    /// Location where site and build assets will be installed. By default
    /// assets will be installed directly in the output location.
    ///
    /// In mulitilingual websites Zine will create a single copy of site
    /// assets which will then be installed at this location. It will be your
    /// duty to then copy this directory elsewhere if needed in your deployment
    /// setup (e.g. when deploying different localized variants to different
    /// hosts). Note that *page* assets will still be installed next to their
    /// relative page.
    assets_prefix_path: []const u8 = "",
    /// Subpaths in `assets_dir_path` that will be installed unconditionally.
    /// All other assets will be installed only if referenced by a content file
    /// or a layout by using `$site.asset('foo').link()`.
    ///
    /// Examples of incorrect usage of this field:
    /// - site-wide CSS files (should be `link`ed by templates)
    /// - RSS feeds (should be generated by defining `alternative` pages)
    ///
    /// Examples of correct usage of this field:
    /// - `favicon.ico` and other similar assets auto-discovered by browsers
    /// - `CNAME` (used by GitHub Pages when you set a custom domain)
    static_assets: []const []const u8 = &.{},
    /// A list of locales of this website.
    ///
    /// For each entry the following values must be unique:
    ///   - `code`
    ///   - `output_prefix_override` (if set) + `host_url_override`
    locales: []const Locale,
    /// When enabled, Zine will automatically add 'width' and 'height'
    /// attributes to <img> elements for local assets.
    /// Be aware that setting 'width' and 'heigth' of an image will in some
    /// circumstances cause browsers to distort images.
    ///
    /// This problem can be solved by adding the following CSS code to your
    /// site:
    ///
    /// img {
    ///    height: auto;
    /// }
    image_size_attributes: bool = false,
};

/// A localized variant of a multilingual website
pub const Locale = struct {
    /// A language-NATION code, e.g. 'en-US', used to identify each
    /// individual localized variant of the website.
    code: []const u8,
    /// A name that identifies this locale, e.g. 'English'
    name: []const u8,
    /// Content dir for this locale,
    content_dir_path: []const u8,
    /// Site title for this locale.
    site_title: []const u8,
    /// Set to a non-null value when deploying this locale from a dedicated
    /// host (e.g. 'https://us.site.com', 'http://de.site.com').
    ///
    /// It must not contain a path other than '/'.
    host_url_override: ?[]const u8 = null,
    /// |  output_ |     host_     |     resulting    |    resulting    |
    /// |  prefix_ |      url_     |        url       |      path       |
    /// | override |   override    |      prefix      |     prefix      |
    /// | -------- | ------------- | ---------------- | --------------- |
    /// |   null   |      null     | site.com/en-US/  | zig-out/en-US/  |
    /// |   null   | "us.site.com" | us.site.com/     | zig-out/en-US/  |
    /// |   "foo"  |      null     | site.com/foo/    | zig-out/foo/    |
    /// |   "foo"  | "us.site.com" | us.site.com/foo/ | zig-out/foo/    |
    /// |    ""    |      null     | site.com/        | zig-out/        |
    ///
    /// The last case is how you create a default locale.
    output_prefix_override: ?[]const u8 = null,
};

pub const Config = union(enum) {
    Multilingual: MultilingualSite,
    Site: Site,

    const config_file_basename = "zine.ziggy";

    /// Returns the cfg and the the directory where the config file was
    /// found. The directory is then used by other code to place the output
    /// directory unless the user overrides that location from the CLI.
    pub fn load(arena: Allocator) struct { Config, []const u8 } {
        errdefer |err| switch (err) {
            error.OutOfMemory => fatal.oom(),
        };

        const cwd_path = std.process.getCwdAlloc(arena) catch |err| {
            fatal.msg("error while trying to get the cwd path: {s}\n", .{
                @errorName(err),
            });
        };

        var base_dir_path: []const u8 = cwd_path;
        while (true) {
            const joined_path = try std.fs.path.join(arena, &.{
                base_dir_path, config_file_basename,
            });

            const data = std.fs.cwd().readFileAllocOptions(
                arena,
                joined_path,
                1024 * 1024,
                null,
                .of(u8),
                0,
            ) catch |err| switch (err) {
                error.FileNotFound => {
                    base_dir_path = std.fs.path.dirname(base_dir_path) orelse {
                        std.debug.print(
                            \\error: unable to find a 'zine.ziggy' config file in this directory or any of its parents
                            \\
                            \\note: run `zine init` in an empty directory to bootstrap a Zine website
                            \\
                            \\
                            \\
                        , .{});

                        fatal.help();
                    };
                    continue;
                },
                else => fatal.file(joined_path, err),
            };

            var diag: ziggy.Diagnostic = .{ .path = joined_path };
            const cfg = ziggy.parseLeaky(Config, arena, data, .{
                .diagnostic = &diag,
                .copy_strings = .to_unescape,
            }) catch {
                fatal.msg(
                    \\Error while loading the Zine config file:
                    \\
                    \\{f}
                    \\
                    \\
                , .{diag.fmt(data)});
            };

            cfg.validate();

            return .{ cfg, base_dir_path };
        }
    }

    pub fn validate(cfg: *const Config) void {
        switch (cfg.*) {
            .Site => |s| {
                const u = std.Uri.parse(s.host_url) catch |err| {
                    fatal.msg("error: host url '{s}' in zine.ziggy is invalid: {s}", .{
                        s.host_url, @errorName(err),
                    });
                };

                if (!u.path.isEmpty()) blk: {
                    switch (u.path) {
                        .raw => {},
                        .percent_encoded => |p| {
                            if (std.mem.eql(u8, p, "/")) break :blk;
                        },
                    }
                    fatal.msg(
                        "error: 'host_url' in zine.ziggy must not contain a path (but contains '{f}'), set 'url_path_prefix' instead",
                        .{std.fmt.alt(u.path, .formatPath)},
                    );
                }

                if (u.query) |q| fatal.msg(
                    "error: 'host_url' in zine.ziggy must not contain a query (but contains '{f}')",
                    .{std.fmt.alt(q, .formatQuery)},
                );

                if (u.fragment) |f| fatal.msg(
                    "error: 'host_url' in zine.ziggy must not contain a fragment (but contains '{f}')",
                    .{std.fmt.alt(f, .formatFragment)},
                );

                const paths: []const []const u8 = &.{
                    s.content_dir_path,
                    s.assets_dir_path,
                    s.layouts_dir_path,
                };

                for (paths) |p| if (validatePathMessage(p, .{})) |msg| fatal.msg(
                    "error: path '{s}' in zine.ziggy: {s}\n",
                    .{ p, msg },
                );

                if (validatePathMessage(
                    s.url_path_prefix,
                    .{ .empty = true },
                )) |msg| fatal.msg(
                    "error: url_path_prefix '{s}' in zine.ziggy: {s}\n",
                    .{ s.url_path_prefix, msg },
                );
            },
            .Multilingual => |ml| {
                const u = std.Uri.parse(ml.host_url) catch |err| {
                    fatal.msg("error: host_url '{s}' in zine.ziggy is invalid: {s}", .{
                        ml.host_url, @errorName(err),
                    });
                };

                if (!u.path.isEmpty()) blk: {
                    switch (u.path) {
                        .raw => {},
                        .percent_encoded => |p| {
                            if (std.mem.eql(u8, p, "/")) break :blk;
                        },
                    }
                    fatal.msg(
                        "error: host_url in zine.ziggy must not contain a path (but contains '{f}'), set 'url_path_prefix' instead",
                        .{std.fmt.alt(u.path, .formatPath)},
                    );
                }

                if (u.query) |q| fatal.msg(
                    "error: host_url in zine.ziggy must not contain a query (but contains '{f}')",
                    .{std.fmt.alt(q, .formatQuery)},
                );

                if (u.fragment) |f| fatal.msg(
                    "error: host_url in zine.ziggy must not contain a fragment (but contains '{f}')",
                    .{std.fmt.alt(f, .formatFragment)},
                );

                const paths: []const []const u8 = &.{
                    ml.i18n_dir_path,
                    ml.assets_dir_path,
                    ml.layouts_dir_path,
                };

                for (paths) |p| if (validatePathMessage(p, .{})) |msg| fatal.msg(
                    "error: path '{s}' in zine.ziggy: {s}\n",
                    .{ p, msg },
                );

                for (ml.locales) |locale| {
                    if (locale.code.len == 0) fatal.msg(
                        "error: empty locale code found in zine.ziggy",
                        .{},
                    );

                    if (validatePathMessage(
                        locale.content_dir_path,
                        .{},
                    )) |msg| fatal.msg(
                        "error: content_dir_path '{s}' in locale '{s}' in zine.ziggy: {s}\n",
                        .{ locale.content_dir_path, locale.code, msg },
                    );

                    if (locale.host_url_override) |url| {
                        const lu = std.Uri.parse(url) catch |err| {
                            fatal.msg("error: host_url_override '{s}' in locale '{s}' in zine.ziggy is invalid: {s}", .{
                                url, locale.code, @errorName(err),
                            });
                        };

                        if (!lu.path.isEmpty()) blk: {
                            switch (lu.path) {
                                .raw => {},
                                .percent_encoded => |p| {
                                    if (std.mem.eql(u8, p, "/")) break :blk;
                                },
                            }
                            fatal.msg(
                                "error: host_url_override in locale '{s}' in zine.ziggy must not contain a path (but contains '{f}'), set 'url_path_prefix' instead",
                                .{
                                    locale.code,
                                    std.fmt.alt(u.path, .formatPath),
                                },
                            );
                        }

                        if (lu.query) |q| fatal.msg(
                            "error: host_url_override in locale '{s}' in zine.ziggy must not contain a query (but contains '{f}')",
                            .{
                                locale.code,
                                std.fmt.alt(q, .formatQuery),
                            },
                        );

                        if (lu.fragment) |f| fatal.msg(
                            "error: host_url_override in locale '{s}' in zine.ziggy must not contain a fragment (but contains '{f}')",
                            .{
                                locale.code,
                                std.fmt.alt(f, .formatFragment),
                            },
                        );
                    }

                    if (locale.output_prefix_override) |opo| {
                        if (validatePathMessage(opo, .{ .empty = true })) |msg| fatal.msg(
                            "error: output_prefix_override '{s}' in locale '{s}' in zine.ziggy: {s}\n",
                            .{ opo, locale.code, msg },
                        );
                    }
                }
            },
        }
    }

    pub fn getLayoutsDirPath(c: *const Config) []const u8 {
        return switch (c.*) {
            .Site => |s| s.layouts_dir_path,
            .Multilingual => |m| m.layouts_dir_path,
        };
    }
    pub fn getAssetsDirPath(c: *const Config) []const u8 {
        return switch (c.*) {
            .Site => |s| s.assets_dir_path,
            .Multilingual => |m| m.assets_dir_path,
        };
    }

    pub fn getStaticAssets(c: *const Config) []const []const u8 {
        return switch (c.*) {
            .Site => |s| s.static_assets,
            .Multilingual => |m| m.static_assets,
        };
    }

    pub fn getImageAutosize(c: *const Config) bool {
        return switch (c.*) {
            .Site => |s| s.image_size_attributes,
            .Multilingual => |m| m.image_size_attributes,
        };
    }

    pub fn getSiteTitle(c: *const Config, locale_id: u32) []const u8 {
        return switch (c.*) {
            .Site => |s| s.title,
            .Multilingual => |m| m.locales[locale_id].site_title,
        };
    }

    pub fn getHostUrl(c: *const Config, locale_id: ?u32) []const u8 {
        return switch (c.*) {
            .Site => |s| s.host_url,
            .Multilingual => |m| if (locale_id) |lid| m.locales[lid].host_url_override orelse m.host_url else m.host_url,
        };
    }
};

// Mirrors closely the corresponding type in build.zig
pub const BuildAsset = struct {
    input_path: []const u8,
    install_path: ?[]const u8 = null,
    install_always: bool = false,
    rc: std.atomic.Value(u32),
};

pub const Options = struct {
    build_assets: *const std.StringArrayHashMapUnmanaged(BuildAsset),
    base_dir_path: []const u8,
    mode: Mode,
    drafts: bool,

    pub const Mode = union(enum) {
        memory,
        disk: struct {
            check_empty_output: bool,
            output_dir_path: ?[]const u8,
        },
    };
};

pub fn run(
    gpa: Allocator,
    cfg: *const Config,
    options: Options,
) error{ OutOfMemory, WriteFailed }!Build {
    assert(worker.started);

    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var build: Build = blk: {
        const tracy_frame = tracy.namedFrame("load build");
        defer tracy_frame.end();
        break :blk Build.load(gpa, cfg, options);
    };
    _ = arena_state.reset(.retain_capacity);

    var static_assets_errors = false;
    {
        const tracy_frame = tracy.namedFrame("scan content");
        defer tracy_frame.end();
        switch (build.cfg.*) {
            .Site => |s| {
                build.variants = try gpa.alloc(Variant, 1);
                worker.addJob(.{
                    .scan = .{
                        .variant = &build.variants[0],
                        .base_dir = build.base_dir,
                        .content_dir_path = s.content_dir_path,
                        .variant_id = 0,
                        .multilingual = null,
                        .output_path_prefix = "",
                    },
                });
            },
            .Multilingual => |ml| {
                build.variants = try gpa.alloc(Variant, ml.locales.len);
                for (ml.locales, 0..) |locale, idx| {
                    worker.addJob(.{
                        .scan = .{
                            .variant = &build.variants[idx],
                            .base_dir = build.base_dir,
                            .content_dir_path = locale.content_dir_path,
                            .variant_id = @intCast(idx),
                            .multilingual = .{
                                .i18n_dir = build.i18n_dir,
                                .i18n_dir_path = ml.i18n_dir_path,
                                .locale_code = locale.code,
                            },
                            .output_path_prefix = locale.output_prefix_override orelse locale.code,
                        },
                    });
                }
            },
        }

        // Before we wait for content dirs to be scanned, we scan the layouts
        // directory in this thread.
        // TODO: find a better moment for this work
        try build.scanTemplates(gpa, arena);
        if (builtin.mode == .Debug) {
            const Ctx = struct {
                b: *Build,
                pub fn lessThan(ctx: @This(), lid: usize, rid: usize) bool {
                    var lbuf: [std.fs.max_path_bytes]u8 = undefined;
                    var rbuf: [std.fs.max_path_bytes]u8 = undefined;

                    const keys = ctx.b.templates.entries.items(.key);
                    const lhs = std.fmt.bufPrint(&lbuf, "{f}", .{
                        keys[lid].fmt(&ctx.b.st, &ctx.b.pt, null, "/"),
                    }) catch unreachable;
                    const rhs = std.fmt.bufPrint(&rbuf, "{f}", .{
                        keys[rid].fmt(&ctx.b.st, &ctx.b.pt, null, "/"),
                    }) catch unreachable;

                    if (std.mem.order(u8, lhs, rhs) == .lt) return true;
                    return false;
                }
            };
            const ctx: Ctx = .{ .b = &build };
            build.templates.sort(ctx);
        }
        try build.scanSiteAssets(gpa, arena);
        _ = arena_state.reset(.retain_capacity);

        for (build.cfg.getStaticAssets()) |path| {
            if (PathName.get(&build.st, &build.pt, path)) |pn| {
                if (build.site_assets.getPtr(pn)) |rc| {
                    rc.raw = 1;
                    continue;
                }
            }

            static_assets_errors = true;
            std.debug.print("error: static asset '{s}' does not exist\n", .{
                path,
            });

            if (build.mode == .memory) {
                try build.mode.memory.errors.append(gpa, .{
                    .ref = "",
                    .msg = try std.fmt.allocPrint(
                        gpa,
                        "error: static asset '{s}' does not exist\n",
                        .{path},
                    ),
                });
            }
        }

        worker.wait(); // variants done scanning their content + i18n ziggy file

    }

    // Activate sections by parsing their index.smd page
    var i18n_errors = false;
    var any_content = false;
    {
        const tracy_frame = tracy.namedFrame("activate sections");
        defer tracy_frame.end();
        for (build.variants) |*v| {
            if (v.i18n_diag.errors.items.len > 0) {
                i18n_errors = true;

                var buf: [std.fs.max_path_bytes]u8 = undefined;
                const path = std.fmt.bufPrint(&buf, "{f}", .{fmtJoin('/', &.{
                    build.cfg.Multilingual.i18n_dir_path,
                    v.i18n_diag.path.?,
                })}) catch v.i18n_diag.path.?;

                v.i18n_diag.path = path;
                std.debug.print("{f}\n\n", .{v.i18n_diag.fmt(v.i18n_src)});
                if (build.mode == .memory) {
                    try build.mode.memory.errors.append(gpa, .{
                        .ref = "",
                        .msg = try std.fmt.allocPrint(
                            gpa,
                            "{f}\n\n",
                            .{v.i18n_diag.fmt(v.i18n_src)},
                        ),
                    });
                }
                continue;
            }

            for (v.sections.items[1..]) |*s| {
                any_content = true;
                worker.addJob(.{
                    .section_activate = .{
                        .variant = v,
                        .section = s,
                        .page = &v.pages.items[s.index],
                        .drafts = options.drafts,
                    },
                });
            }
        }

        // Stop if there is no content at all to analyze
        if (!any_content) fatal.msg(
            "No content found, start by adding a index.smd file to your content directory.\n",
            .{},
        );

        // Never a deadlock because we check that at least one page exists.
        worker.wait(); // sections have been activated (by parsing index.smd files)

    }
    // This second time, as we scan sections, we also propagate section
    // deactivation downward to avoid processing unreachable sections.
    var pages_to_parse: usize = 0;
    var progress_parse = progress.start("Parse pages", 0);
    var sites: std.StringArrayHashMapUnmanaged(context.Site) = .empty;
    {
        const tracy_frame = tracy.namedFrame("parse pages");
        defer tracy_frame.end();
        for (build.variants) |*v| {
            v.sections.items[0].active = true;
            for (v.sections.items[1..], 1..) |*s, idx| {
                assert(s.parent_section < idx);
                // This will access section 0 but its 'active' field is set correctly
                // (see 4 lines above)
                const parent = v.sections.items[s.parent_section];
                log.debug("section {} parent {} parent.active = {}", .{
                    idx, s.parent_section, parent.active,
                });

                if (!parent.active) s.active = false;
                if (!s.active) continue;

                pages_to_parse += s.pages.items.len;

                const index_smd: String = @enumFromInt(1);
                assert(v.string_table.get("index.smd").? == index_smd);
                for (s.pages.items) |page_index| {
                    const p = &v.pages.items[page_index];
                    if (p._scan.file.name == index_smd) continue; // already parsed
                    worker.addJob(.{
                        .page_parse = .{
                            .progress = progress_parse,
                            .drafts = options.drafts,
                            .variant = v,
                            .page = p,
                        },
                    });
                }
            }
        }

        progress_parse.setEstimatedTotalItems(pages_to_parse);

        switch (build.cfg.*) {
            .Site => |s| {
                try sites.putNoClobber(gpa, "", .{
                    .host_url = s.host_url,
                    .title = s.title,
                    ._meta = .{
                        .variant_id = 0,
                        .kind = .{ .simple = s.url_path_prefix },
                    },
                });
            },
            .Multilingual => |ml| {
                try sites.ensureTotalCapacity(gpa, build.variants.len);
                for (ml.locales, 0..) |loc, idx| sites.putAssumeCapacityNoClobber(loc.code, .{
                    .host_url = loc.host_url_override orelse ml.host_url,
                    .title = loc.site_title,
                    ._meta = .{
                        .variant_id = @intCast(idx),
                        .kind = .{
                            .multi = loc,
                        },
                    },
                });
            },
        }

        // In case all pages are section indexes, we might have content but no
        // pages to analyze at thist stage.
        if (pages_to_parse > 0) worker.wait(); // all active pages have been loaded and parsed
        progress_parse.end();
    }

    // TODO: move this onto the worker pool, we don't need to look at
    // sections for a while after this point, ideally it would require
    // having its own waitgroup.
    for (build.variants) |*v| {
        for (v.sections.items[1..]) |*s| {
            s.sortPages(v, v.pages.items);
        }
    }

    var parse_errors = false;
    var pages_to_analyze: usize = 0;
    var progress_page_analyze = progress.start("Analyze pages", 0);
    {
        const tracy_frame = tracy.namedFrame("analyze pages");
        defer tracy_frame.end();
        for (build.variants, 0..) |*v, vidx| {
            for (v.pages.items) |*p| {
                if (!p._parse.active) continue;

                // First we need to check the frontmatter as the ast will be valid
                // only if the frontmatter has been correctly identifed as well.
                switch (p._parse.status) {
                    .empty => {
                        // Page is empty, print warning and skip it
                        std.debug.print("WARNING: Ignoring empty file '{f}'\n", .{
                            p._scan.file.fmt(
                                &v.string_table,
                                &v.path_table,
                                v.content_dir_path,
                                "",
                            ),
                        });
                        continue;
                    },

                    .frontmatter => |err| {
                        if (!parse_errors) {
                            parse_errors = true;
                        }

                        const note = switch (err) {
                            error.MissingFrontmatter => "the document doesn't start with '---' (a frontmatter frame delimiter)",
                            error.OpenFrontmatter => "the frontmatter is missing a closing '---' frontmatter delimiter",
                        };

                        std.debug.print(
                            \\{f}:{}:1 error: frontmatter framing error
                            \\   {s}
                            \\
                        , .{
                            p._scan.file.fmt(
                                &v.string_table,
                                &v.path_table,
                                v.content_dir_path,
                                "",
                            ),
                            p._parse.fm.lines,
                            note,
                        });
                        if (build.mode == .memory) {
                            try build.mode.memory.errors.append(gpa, .{
                                .ref = "",
                                .msg = try std.fmt.allocPrint(gpa,
                                    \\{f}:{}:1 error: frontmatter framing error
                                    \\   {s}
                                    \\
                                , .{
                                    p._scan.file.fmt(
                                        &v.string_table,
                                        &v.path_table,
                                        v.content_dir_path,
                                        "",
                                    ),
                                    p._parse.fm.lines,
                                    note,
                                }),
                            });
                        }
                        continue;
                    },

                    .ziggy => |*diag| {
                        if (!parse_errors) {
                            parse_errors = true;
                        }

                        var buf: [std.fs.max_path_bytes]u8 = undefined;
                        const path = std.fmt.bufPrint(&buf, "{f}", .{
                            p._scan.file.fmt(
                                &v.string_table,
                                &v.path_table,
                                v.content_dir_path,
                                "",
                            ),
                        }) catch unreachable;

                        diag.path = path;
                        std.debug.print("{f}\n\n", .{diag.fmt(p._parse.full_src)});
                        if (build.mode == .memory) {
                            try build.mode.memory.errors.append(gpa, .{
                                .ref = "",
                                .msg = try std.fmt.allocPrint(
                                    gpa,
                                    "{f}\n\n",
                                    .{diag.fmt(p._parse.full_src)},
                                ),
                            });
                        }
                        continue;
                    },

                    .parsed => {},
                }

                // Validate the layout field and reference count so that we
                // know to only load layouts that are actively used.
                // This is in some ways frontmatter analysis work but it's done
                // here because we have to scan all pages for errors anyway.
                layout: {
                    const layout_pn = PathName.get(&build.st, &build.pt, p.layout) orelse {
                        // We can use analysis because a page that has
                        // parse.status == ok will have initialized the field for
                        // us.
                        try p._analysis.frontmatter.append(gpa, .layout);
                        break :layout;
                    };

                    const layout = build.templates.getPtr(layout_pn) orelse {
                        try p._analysis.frontmatter.append(gpa, .layout);
                        break :layout;
                    };

                    if (!layout.layout) {
                        // TODO: create a dedicated error
                        try p._analysis.frontmatter.append(gpa, .layout);
                        break :layout;
                    }

                    for (p.alternatives, 0..) |alt, aidx| {
                        const alt_layout_pn = PathName.get(
                            &build.st,
                            &build.pt,
                            alt.layout,
                        ) orelse {
                            try p._analysis.frontmatter.append(gpa, .{
                                .alternative = .{
                                    .id = @intCast(aidx),
                                    .kind = .layout,
                                },
                            });
                            break :layout;
                        };

                        const alt_layout = build.templates.getPtr(alt_layout_pn) orelse {
                            // We can use analysis because a page that has
                            // parse.status == ok will have initialized the field for
                            // us.
                            try p._analysis.frontmatter.append(gpa, .{
                                .alternative = .{
                                    .id = @intCast(aidx),
                                    .kind = .layout,
                                },
                            });
                            break :layout;
                        };

                        if (!alt_layout.layout) {
                            // TODO: create a dedicated error
                            try p._analysis.frontmatter.append(gpa, .{
                                .alternative = .{
                                    .id = @intCast(aidx),
                                    .kind = .layout,
                                },
                            });
                            break :layout;
                        }
                    }
                }

                if (p._parse.ast.errors.len > 0) {
                    if (!parse_errors) {
                        parse_errors = true;
                    }

                    printSuperMdErrors(
                        gpa,
                        arena,
                        &build,
                        v,
                        p._scan.file,
                        &p._parse.ast,
                        p._parse.full_src[p._parse.fm.offset..],
                        p._parse.fm.lines - 1,
                    );

                    // Do not schedule the page for analysis if contains parsing
                    // errors.
                    continue;
                }

                // Reaching here means that the page does not contain any parsing
                // error and can be scheduled for analysis. In theory we could
                // isolate ziggy syntax errors and only analize the supermd content
                // but it seems a minor optimization not worth the extra complexity.
                pages_to_analyze += 1;
                worker.addJob(.{
                    .page_analyze = .{
                        .progress = progress_page_analyze,
                        .build = &build,
                        .variant_id = @intCast(vidx),
                        .page = p,
                    },
                });
            }
        }

        progress_page_analyze.setEstimatedTotalItems(pages_to_analyze);
        worker.wait(); // pages have been analyzed
        progress_page_analyze.end();
    }

    for (build.templates.keys(), build.templates.values()) |tpn, *template| {
        worker.addJob(.{
            .template_parse = .{
                .build = &build,
                .template = template,
                .pn = tpn,
            },
        });
    }

    var analysis_errors = false;
    for (build.variants) |*v| {
        for (v.pages.items) |*p| {
            if (!p._parse.active) continue;
            if (p._parse.status != .parsed) continue;

            const fm_errors = &p._analysis.frontmatter;
            if (fm_errors.items.len > 0) {
                if (!analysis_errors) {
                    analysis_errors = true;
                }

                const full_src = p._parse.full_src;
                const ast = ziggy.Ast.init(
                    arena,
                    full_src,
                    false,
                    true,
                    true,
                    null,
                ) catch unreachable;

                for (fm_errors.items) |err| {
                    const loc = err.location(full_src, ast);
                    const sel = loc.getSelection(full_src);
                    const line_off = loc.line(full_src);

                    const line_trim_left = std.mem.trimLeft(u8, line_off.line, &std.ascii.whitespace);
                    const start_trim_left = line_off.start + line_off.line.len - line_trim_left.len;

                    const caret_len = loc.end - loc.start;
                    const caret_spaces_len = loc.start -| start_trim_left;

                    const line_trim = std.mem.trimRight(u8, line_trim_left, &std.ascii.whitespace);

                    var hl_buf: [1024]u8 = undefined;

                    const highlight = if (caret_len + caret_spaces_len < 1024) blk: {
                        const h = hl_buf[0 .. caret_len + caret_spaces_len];
                        @memset(h[0..caret_spaces_len], ' ');
                        @memset(h[caret_spaces_len..][0..caret_len], '^');
                        break :blk h;
                    } else "";

                    std.debug.print(
                        \\{f}:{}:{}: error: {s}
                        \\|    {s}
                        \\|    {s}
                        \\
                        \\
                    , .{
                        p._scan.file.fmt(
                            &v.string_table,
                            &v.path_table,
                            v.content_dir_path,
                            "",
                        ),
                        sel.start.line,
                        sel.start.col,
                        err.title(),
                        line_trim,
                        highlight,
                    });
                    if (build.mode == .memory) {
                        try build.mode.memory.errors.append(gpa, .{
                            .ref = "",
                            .msg = try std.fmt.allocPrint(gpa,
                                \\{f}:{}:{}: error: {s}
                                \\|    {s}
                                \\|    {s}
                                \\
                                \\
                            , .{
                                p._scan.file.fmt(
                                    &v.string_table,
                                    &v.path_table,
                                    v.content_dir_path,
                                    "",
                                ),
                                sel.start.line,
                                sel.start.col,
                                err.title(),
                                line_trim,
                                highlight,
                            }),
                        });
                    }
                }
            }

            const page_errors = &p._analysis.page;
            if (page_errors.items.len > 0) {
                if (!analysis_errors) {
                    analysis_errors = true;
                }

                for (page_errors.items) |err| {
                    const n = err.node;
                    const range = n.range();
                    const md_src = p._parse.full_src[p._parse.fm.offset..];
                    const line = blk: {
                        var it = std.mem.splitScalar(u8, md_src, '\n');
                        for (1..range.start.row) |_| _ = it.next();
                        break :blk it.next().?;
                    };

                    const line_trim_left = std.mem.trimLeft(
                        u8,
                        line,
                        &std.ascii.whitespace,
                    );

                    const line_trim = std.mem.trimRight(u8, line_trim_left, &std.ascii.whitespace);

                    const start_trim_left = line.len - line_trim_left.len;
                    const caret_len = if (range.start.row == range.end.row)
                        range.end.col - range.start.col
                    else
                        line_trim.len - start_trim_left;
                    const caret_spaces_len = range.start.col - 1 - start_trim_left;

                    var hl_buf: [1024]u8 = undefined;

                    const highlight = if (caret_len + caret_spaces_len + 1 < 1024) blk: {
                        const h = hl_buf[0 .. caret_len + caret_spaces_len + 1];
                        @memset(h[0..caret_spaces_len], ' ');
                        @memset(h[caret_spaces_len..][0 .. caret_len + 1], '^');
                        break :blk h;
                    } else "";

                    const fm_lines = p._parse.fm.lines;
                    std.debug.print(
                        \\{f}:{}:{}: error: {s}
                        \\|    {s}
                        \\|    {s}
                        \\
                        \\
                    , .{
                        p._scan.file.fmt(
                            &v.string_table,
                            &v.path_table,
                            v.content_dir_path,
                            "",
                        ),
                        fm_lines + n.startLine(),
                        n.startColumn(),
                        err.title(),
                        line_trim,
                        highlight,
                    });
                    if (build.mode == .memory) {
                        try build.mode.memory.errors.append(gpa, .{
                            .ref = "",
                            .msg = try std.fmt.allocPrint(gpa,
                                \\{f}:{}:{}: error: {s}
                                \\|    {s}
                                \\|    {s}
                                \\
                                \\
                            , .{
                                p._scan.file.fmt(
                                    &v.string_table,
                                    &v.path_table,
                                    v.content_dir_path,
                                    "",
                                ),
                                fm_lines + n.startLine(),
                                n.startColumn(),
                                err.title(),
                                line_trim,
                                highlight,
                            }),
                        });
                    }
                }
            }
        }
    }

    if (build.cfg.* == .Multilingual) {
        for (build.variants, 0..) |*v, vidx| {
            for (v.pages.items) |*p| {
                if (!p._parse.active) continue;
                if (p._parse.status != .parsed) continue;

                // Translation keys
                if (p.translation_key) |tk| {
                    const gop = try build.tks.getOrPut(gpa, tk);
                    const locales = if (!gop.found_existing) blk: {
                        const locales = try gpa.alloc(?*context.Page, build.variants.len);
                        @memset(locales, null);
                        gop.value_ptr.* = locales;
                        break :blk locales;
                    } else gop.value_ptr.*;

                    if (locales[vidx] != null) {
                        @panic("TODO: a traslation key collision was found, make a nice error for it");
                    }

                    locales[vidx] = p;
                }
            }
        }
    }

    // Output URL collision detection.
    // This code solves a simplified version of actual URL collision.
    // It assumes that directories can't have dots in their name, and
    // that files always have an extension, reducing collision detection
    // to just detecting duplicate paths. This simplified version
    // of the problem can be solved with a hash map, while solving the
    // full version will require using a tree for the live server
    // and perhaps some clever scan algorithm in the `zine release` case.
    // Alternatively, if this algo proves to be sufficiently more efficent
    // than the tree case, we could default to this method and then only
    // switch to the more expensive approach if necessary.
    if (!parse_errors) {
        const tracy_frame = tracy.namedFrame("url collision");
        defer tracy_frame.end();
        for (build.variants) |*v| {
            for (v.pages.items, 0..) |*p, pidx| {
                if (!p._parse.active) continue;
                if (p._parse.status != .parsed) continue;

                // page_main
                // const smd_out_dir_path: []const String = smd_out: {
                //     const path = p._scan.md_path;
                //     const name = p._scan.md_name;

                //     const index_smd: String = @enumFromInt(1);
                //     const index_html: String = @enumFromInt(11);
                //     assert(v.string_table.get("index.smd").? == index_smd);
                //     assert(v.string_table.get("index.html").? == index_html);
                //     const url: PathName = blk: {
                //         if (name == index_smd) break :blk .{
                //             .path = path,
                //             .name = index_html,
                //         };

                //         const name_no_ext = std.fs.path.stem(name.slice(&v.string_table));
                //         break :blk .{
                //             .path = try v.path_table.internExtend(
                //                 gpa,
                //                 path.slice(&v.path_table),
                //                 try v.string_table.intern(gpa, name_no_ext),
                //             ),
                //             .name = index_html,
                //         };
                //     };

                //     const loc: Variant.LocationHint = .{
                //         .kind = .page_main,
                //         .id = @intCast(pidx),
                //     };

                //     const gop = try v.urls.getOrPut(gpa, url);
                //     if (!gop.found_existing) {
                //         gop.value_ptr.* = loc;
                //     } else {
                //         try v.collisions.append(gpa, .{
                //             .url = url,
                //             .loc = loc,
                //             .previous = gop.value_ptr.*,
                //         });
                //     }
                //     break :smd_out url.path.slice(&v.path_table);
                // };

                // aliases
                for (p.aliases) |a| {
                    assert(std.mem.indexOfScalar(u8, a, '\\') == null);
                    assert(std.fs.path.extension(a).len > 0);
                    assert(std.mem.indexOfScalar(
                        u8,
                        std.fs.path.dirnamePosix(a) orelse "",
                        '.',
                    ) == null);

                    const prefix = if (a[0] == '/')
                        &.{}
                    else blk: {
                        const prefix = p._scan.url.slice(&v.path_table);
                        try v.path_table.path_components.ensureUnusedCapacity(
                            gpa,
                            prefix.len + std.mem.count(u8, a, "/"),
                        );
                        break :blk prefix;
                    };

                    const url = try v.path_table.internPathWithName(
                        gpa,
                        &v.string_table,
                        prefix,
                        a,
                    );

                    const loc: Variant.LocationHint = .{
                        .kind = .page_alias,
                        .id = @intCast(pidx),
                    };

                    const gop = try v.urls.getOrPut(gpa, url);
                    if (!gop.found_existing) {
                        gop.value_ptr.* = loc;
                    } else {
                        try v.collisions.append(gpa, .{
                            .url = url,
                            .loc = loc,
                            .previous = gop.value_ptr.*,
                        });
                    }
                }

                // alternatives
                for (p.alternatives) |alt| {
                    assert(std.mem.indexOfScalar(u8, alt.output, '\\') == null);
                    assert(std.fs.path.extension(alt.output).len > 0);
                    assert(std.mem.indexOfScalar(
                        u8,
                        std.fs.path.dirnamePosix(alt.output) orelse "",
                        '.',
                    ) == null);

                    const prefix = if (alt.output[0] == '/')
                        &.{}
                    else blk: {
                        const prefix = p._scan.url.slice(&v.path_table);
                        try v.path_table.path_components.ensureUnusedCapacity(
                            gpa,
                            prefix.len + std.mem.count(u8, alt.output, "/") + 1,
                        );
                        break :blk p._scan.url.slice(&v.path_table);
                    };
                    const url = try v.path_table.internPathWithName(
                        gpa,
                        &v.string_table,
                        prefix,
                        alt.output,
                    );

                    const loc: Variant.LocationHint = .{
                        .kind = .{ .page_alternative = alt.name },
                        .id = @intCast(pidx),
                    };

                    const gop = try v.urls.getOrPut(gpa, url);
                    if (!gop.found_existing) {
                        gop.value_ptr.* = loc;
                    } else {
                        try v.collisions.append(gpa, .{
                            .url = url,
                            .loc = loc,
                            .previous = gop.value_ptr.*,
                        });
                    }
                }
            }
        }
    }

    var collision_errors = false;
    for (build.variants) |v| {
        if (v.collisions.items.len > 0) {
            if (!collision_errors) {
                collision_errors = true;
            }
            for (v.collisions.items) |c| {
                std.debug.print(
                    \\{f}: error: output url collision detected
                    \\   between  {f}
                    \\   and      {f}
                    \\
                    \\
                , .{
                    c.url.fmt(&v.string_table, &v.path_table, null, ""),
                    c.previous.fmt(&v.string_table, &v.path_table, v.pages.items),
                    c.loc.fmt(&v.string_table, &v.path_table, v.pages.items),
                });
                if (build.mode == .memory) {
                    try build.mode.memory.errors.append(gpa, .{
                        .ref = "",
                        .msg = try std.fmt.allocPrint(gpa,
                            \\{f}: error: output url collision detected
                            \\   between  {f}
                            \\   and      {f}
                            \\
                            \\
                        , .{
                            c.url.fmt(&v.string_table, &v.path_table, null, ""),
                            c.previous.fmt(&v.string_table, &v.path_table, v.pages.items),
                            c.loc.fmt(&v.string_table, &v.path_table, v.pages.items),
                        }),
                    });
                }
            }
        }
    }

    worker.wait(); // layouts are done loading

    var template_errors = false;
    for (build.templates.keys(), build.templates.values()) |tpn, *template| {
        if (template.html_ast.errors.len > 0) {
            if (!template_errors) {
                template_errors = true;
            }

            const path = try std.fmt.allocPrint(arena, "{f}", .{
                tpn.fmt(
                    &build.st,
                    &build.pt,
                    build.cfg.getLayoutsDirPath(),
                    "/",
                ),
            });

            std.debug.lockStdErr();
            defer std.debug.unlockStdErr();

            var aw: Writer.Allocating = .init(gpa);
            defer if (build.mode != .memory) aw.deinit();

            try template.html_ast.printErrors(
                template.src,
                path,
                &aw.writer,
            );

            std.debug.print("{s}", .{aw.written()});
            if (build.mode == .memory) {
                try build.mode.memory.errors.append(gpa, .{
                    .ref = "",
                    .msg = aw.written(),
                });
            }
            continue;
        }

        if (template.ast.errors.len > 0) {
            if (!template_errors) {
                template_errors = true;
            }

            const path = try std.fmt.allocPrint(arena, "{f}", .{
                tpn.fmt(
                    &build.st,
                    &build.pt,
                    build.cfg.getLayoutsDirPath(),
                    "/",
                ),
            });

            var aw: Writer.Allocating = .init(gpa);
            defer if (build.mode != .memory) aw.deinit();

            template.ast.printErrors(
                template.src,
                path,
                &aw.writer,
            ) catch return error.OutOfMemory;

            std.debug.print("{s}", .{aw.written()});
            if (build.mode == .memory) {
                try build.mode.memory.errors.append(gpa, .{
                    .ref = "",
                    .msg = aw.written(),
                });
            }
        }

        if (template.missing_parent) {
            if (!template_errors) {
                template_errors = true;
            }
            const path = try std.fmt.allocPrint(arena, "{f}", .{
                tpn.fmt(
                    &build.st,
                    &build.pt,
                    build.cfg.getLayoutsDirPath(),
                    "/",
                ),
            });

            const parent_name = template.ast.nodes[template.ast.extends_idx].templateValue().span.slice(template.src);
            std.debug.print(
                \\{s}: error: extending a template that doesn't exist 
                \\   template '{s}' does not exist
                \\
            , .{
                path, parent_name,
            });
            if (build.mode == .memory) {
                try build.mode.memory.errors.append(gpa, .{
                    .ref = "",
                    .msg = try std.fmt.allocPrint(gpa,
                        \\{s}: error: extending a template that doesn't exist 
                        \\   template '{s}' does not exist
                        \\
                    , .{
                        path, parent_name,
                    }),
                });
            }
        }
    }

    if (static_assets_errors or i18n_errors or collision_errors or
        parse_errors or analysis_errors or template_errors)
    {
        build.any_prerendering_error = true;
        return build;
    }

    var pages_to_render: usize = 0;
    var progress_page_render = progress.start("Render pages", 0);
    {
        const tracy_frame = tracy.namedFrame("page rendering");
        defer tracy_frame.end();
        for (build.variants) |*v| {
            for (v.pages.items) |*p| {
                if (!p._parse.active) continue;
                if (p._parse.status != .parsed) continue;
                if (p._analysis.frontmatter.items.len > 0) continue;
                if (p._analysis.page.items.len > 0) continue;

                pages_to_render += 1;

                if (builtin.single_threaded) std.debug.print("Rendering {}...\n", .{
                    p._scan.file.fmt(&v.string_table, &v.path_table, v.content_dir_path),
                });

                p._render = .{
                    .out = undefined,
                    .errors = undefined,
                    .alternatives = try gpa.alloc(
                        @typeInfo(@TypeOf(p._render.alternatives)).pointer.child,
                        p.alternatives.len,
                    ),
                };

                worker.addJob(.{
                    .page_render = .{
                        .progress = progress_page_render,
                        .build = &build,
                        .sites = &sites,
                        .page = p,
                        .kind = .main,
                    },
                });

                for (0..p.alternatives.len) |aidx| {
                    worker.addJob(.{
                        .page_render = .{
                            .progress = progress_page_render,
                            .build = &build,
                            .sites = &sites,
                            .page = p,
                            .kind = .{ .alternative = @intCast(aidx) },
                        },
                    });
                }
            }
        }

        progress_page_render.setEstimatedTotalItems(pages_to_render);
        worker.wait(); // pages done rendering
        progress_page_render.end();
    }

    if (build.mode == .memory) return build;

    var progress_install_assets = progress.start("Install assets", 0);
    for (build.variants) |*v| {
        worker.addJob(.{
            .variant_assets_install = .{
                .progress = progress_install_assets,
                .install_dir = build.mode.disk.output_dir,
                .variant = v,
            },
        });
    }

    // install site assets
    {
        const site_assets_install_dir = switch (build.cfg.*) {
            .Site => build.mode.disk.output_dir,
            .Multilingual => |ml| blk: {
                if (ml.assets_prefix_path.len == 0) {
                    break :blk build.mode.disk.output_dir;
                } else {
                    break :blk build.mode.disk.output_dir.openDir(
                        ml.assets_prefix_path,
                        .{},
                    ) catch |err| fatal.dir(ml.assets_prefix_path, err);
                }
            },
        };

        var buf: [std.fs.max_path_bytes]u8 = undefined;
        var site_it = build.site_assets.iterator();
        while (site_it.next()) |entry| {
            const key = entry.key_ptr.*;
            // const path = buf[0..key.path.bytesSlice(
            //     &build.st,
            //     &build.pt,
            //     &buf,
            //     std.fs.path.sep,
            //     key.name,
            // )];

            const path = std.fmt.bufPrint(&buf, "{f}", .{
                key.fmt(
                    &build.st,
                    &build.pt,
                    null,
                    "",
                ),
            }) catch unreachable;

            if (entry.value_ptr.raw > 0) {
                _ = build.site_assets_dir.updateFile(
                    path,
                    site_assets_install_dir,
                    std.mem.trimLeft(u8, path, "/"),
                    .{},
                ) catch |err| fatal.file(path, err);
            }
        }
    }

    // install build assets
    {
        for (build.build_assets.values()) |ba| {
            // Avoid installing if already rc'd
            if (ba.rc.load(.acquire) > 0) {
                _ = build.base_dir.updateFile(
                    ba.input_path,
                    build.mode.disk.output_dir,
                    std.mem.trimLeft(u8, ba.install_path.?, "/"),
                    .{},
                ) catch |err| fatal.file(ba.input_path, err);
            }
        }
    }

    worker.wait(); // done installing assets
    progress_install_assets.end();

    return build;
}

fn printSuperMdErrors(
    gpa: Allocator,
    arena: Allocator,
    build: *Build,
    v: *Variant,
    file: PathName,
    ast: *const supermd.Ast,
    md_src: []const u8,
    fm_lines: usize,
) void {
    _ = arena;
    errdefer |err| switch (err) {
        error.OutOfMemory => fatal.oom(),
    };

    // \\It's strongly recommended to setup your editor to
    // \\leverage the `supermd` CLI tool in order to obtain
    // \\in-editor syntax checking and autoformatting.
    // \\
    // \\Download it from here:
    // \\   https://github.com/kristoff-it/supermd

    for (ast.errors) |err| {
        const range = err.main;
        const line = blk: {
            var it = std.mem.splitScalar(u8, md_src, '\n');
            for (1..range.start.row) |_| _ = it.next();
            break :blk it.next().?;
        };

        const line_trim_left = std.mem.trimLeft(
            u8,
            line,
            &std.ascii.whitespace,
        );

        const line_trim = std.mem.trimRight(u8, line_trim_left, &std.ascii.whitespace);

        const start_trim_left = line.len - line_trim_left.len;
        const caret_len = if (range.start.row == range.end.row)
            range.end.col - range.start.col
        else
            line_trim.len - start_trim_left;
        const caret_spaces_len = range.start.col - 1 - start_trim_left;

        var buf: [1024]u8 = undefined;

        const extra: u32 = if (err.kind == .scripty) 1 else 0;

        const highlight = if (caret_len + caret_spaces_len + extra < 1024) blk: {
            const h = buf[0 .. caret_len + caret_spaces_len + extra];
            @memset(h[0..caret_spaces_len], ' ');
            @memset(h[caret_spaces_len..][0 .. caret_len + extra], '^');
            break :blk h;
        } else "";

        const msg = switch (err.kind) {
            .scripty => |s| s.err,
            else => "",
        };

        // std.debug.print("fm: {}\nerr: {s}\nrange start: {any}\nrange end: {any}", .{
        //     fm_offset,
        //     @tagName(err.kind),
        //     err.main.start,
        //     err.main.end,
        // });

        // Saturating subtraction because of a bug related to html comments
        // in markdown.
        // const lines = range.end.row -| range.start.row;
        // const lines_fmt = if (lines == 0) "" else try std.fmt.allocPrint(
        //     arena,
        //     "(+{} lines)",
        //     .{lines},
        // );

        const tag_name = switch (err.kind) {
            .html => |h| switch (h.tag) {
                inline else => |t| @tagName(t),
            },
            else => @tagName(err.kind),
        };
        std.debug.print(
            \\{f}:{}:{}: [{s}] {s}
            \\|    {s}
            \\|    {s}
            \\
        , .{
            file.fmt(&v.string_table, &v.path_table, v.content_dir_path, ""),
            fm_lines + range.start.row,
            range.start.col,
            tag_name,
            msg,
            line_trim,
            highlight,
        });

        switch (err.kind) {
            .duplicate_id => |dup| {
                std.debug.print(
                    \\|   note: original was defined on line {}
                    \\
                    \\
                , .{fm_lines + dup.original.range().start.row});
            },
            else => std.debug.print("\n", .{}),
        }

        if (build.mode == .memory) {
            try build.mode.memory.errors.append(gpa, .{
                .ref = "",
                .msg = try std.fmt.allocPrint(gpa,
                    \\{f}:{}:{}: [{s}] {s}
                    \\|    {s}
                    \\|    {s}
                    \\
                    \\
                , .{
                    file.fmt(&v.string_table, &v.path_table, v.content_dir_path, ""),
                    fm_lines + range.start.row,
                    range.start.col,
                    tag_name,
                    msg,
                    line_trim,
                    highlight,
                }),
            });
        }
    }
}

pub fn fmtJoin(sep: u8, paths: []const []const u8) FormatJoin {
    return .{ .paths = paths, .sep = sep };
}

pub const FormatJoin = struct {
    paths: []const []const u8,
    sep: u8,

    pub fn format(fj: FormatJoin, w: *Writer) !void {
        assert(fj.sep == '/' or fj.sep == '\\');

        const paths = fj.paths;
        const separator = fj.sep;

        const first_path_idx = for (paths, 0..) |p, idx| {
            if (p.len != 0) break idx;
        } else return;

        try w.writeAll(paths[first_path_idx]); // first component
        var prev_path = paths[first_path_idx];
        for (paths[first_path_idx + 1 ..]) |this_path| {
            if (this_path.len == 0) continue; // skip empty components
            const prev_sep = prev_path[prev_path.len - 1] == separator;
            const this_sep = this_path[0] == separator;
            if (!prev_sep and !this_sep) {
                try w.writeByte(separator);
            }
            if (prev_sep and this_sep) {
                try w.writeAll(this_path[1..]); // skip redundant separator
            } else {
                try w.writeAll(this_path);
            }
            prev_path = this_path;
        }
    }
};

pub fn join(allocator: std.mem.Allocator, paths: []const []const u8, separator: u8) ![]u8 {
    // Find first non-empty path index.
    const first_path_index = blk: {
        for (paths, 0..) |path, index| {
            if (path.len == 0) continue else break :blk index;
        }

        // All paths provided were empty, so return early.
        return &[0]u8{};
    };

    // Calculate length needed for resulting joined path buffer.
    const total_len = blk: {
        var sum: usize = paths[first_path_index].len;
        var prev_path = paths[first_path_index];
        std.debug.assert(prev_path.len > 0);
        var i: usize = first_path_index + 1;
        while (i < paths.len) : (i += 1) {
            const this_path = paths[i];
            if (this_path.len == 0) continue;
            const prev_sep = prev_path[prev_path.len - 1] == separator;
            const this_sep = this_path[0] == separator;
            sum += @intFromBool(!prev_sep and !this_sep);
            sum += if (prev_sep and this_sep) this_path.len - 1 else this_path.len;
            prev_path = this_path;
        }

        break :blk sum;
    };

    const buf = try allocator.alloc(u8, total_len);
    errdefer allocator.free(buf);

    @memcpy(buf[0..paths[first_path_index].len], paths[first_path_index]);
    var buf_index: usize = paths[first_path_index].len;
    var prev_path = paths[first_path_index];
    std.debug.assert(prev_path.len > 0);
    var i: usize = first_path_index + 1;
    while (i < paths.len) : (i += 1) {
        const this_path = paths[i];
        if (this_path.len == 0) continue;
        const prev_sep = prev_path[prev_path.len - 1] == separator;
        const this_sep = this_path[0] == separator;
        if (!prev_sep and !this_sep) {
            buf[buf_index] = separator;
            buf_index += 1;
        }
        const adjusted_path = if (prev_sep and this_sep) this_path[1..] else this_path;
        @memcpy(buf[buf_index..][0..adjusted_path.len], adjusted_path);
        buf_index += adjusted_path.len;
        prev_path = this_path;
    }

    // No need for shrink since buf is exactly the correct size.
    return buf;
}

//NOTE: this must be kept in sync with SuperMD
pub const PathValidationOptions = struct { empty: bool = false };
pub fn validatePathMessage(
    path: []const u8,
    // Toggle checks
    options: PathValidationOptions,
) ?[]const u8 {
    validatePath(path, options) catch |err| return switch (err) {
        error.Spaces => "remove whitespace surrounding path",
        error.Empty => "this builtin does not accept empty paths",
        error.AbsolutePath => "absolute paths are not allowed here",
        error.Backslash => "use '/' instead of '\\' as the path component separator",
        error.Dots => "'.' and '..' are not allowed",
        error.EmptyComponent => "empty component in path",
    };

    return null;
}

//NOTE: this must be kept in sync with SuperMD
pub fn validatePath(
    path: []const u8,
    // Toggle checks
    options: PathValidationOptions,
) !void {
    // Paths must not have spaces around them
    const spaces = std.mem.trim(u8, path, &std.ascii.whitespace);
    if (spaces.len != path.len) return error.Spaces;

    // Paths cannot be empty unless we allow it
    if (path.len == 0 and options.empty) return;
    if (path.len == 0) return error.Empty;

    // All paths must be relative
    if (path[0] == '/') return error.AbsolutePath;

    // Paths cannot contain Windows-style separators
    if (std.mem.indexOfScalar(u8, path, '\\') != null) return error.Backslash;

    // Path cannot contain any relative component (. or ..)
    var it = std.mem.splitScalar(u8, path, '/');
    while (it.next()) |c| {
        if (std.mem.eql(u8, c, ".") or
            std.mem.eql(u8, c, "..")) return error.Dots;

        if (c.len == 0) {
            if (it.next() != null) return error.EmptyComponent;
        }
    }
}
